[READ-ONLY] a fast, modern browser for the npm registry
at main 208 lines 6.6 kB view raw
1import * as v from 'valibot' 2import { PackageFileQuerySchema } from '#shared/schemas/package' 3import type { ReadmeResponse } from '#shared/types/readme' 4import { 5 CACHE_MAX_AGE_ONE_YEAR, 6 ERROR_PACKAGE_VERSION_AND_FILE_FAILED, 7} from '#shared/utils/constants' 8 9const CACHE_VERSION = 3 10 11// Maximum file size to fetch and highlight (500KB) 12const MAX_FILE_SIZE = 500 * 1024 13 14// Languages that benefit from import linking 15const IMPORT_LANGUAGES = new Set([ 16 'javascript', 17 'typescript', 18 'jsx', 19 'tsx', 20 'vue', 21 'svelte', 22 'astro', 23]) 24 25interface PackageJson { 26 dependencies?: Record<string, string> 27 devDependencies?: Record<string, string> 28 peerDependencies?: Record<string, string> 29 optionalDependencies?: Record<string, string> 30} 31 32/** 33 * Fetch package.json from jsDelivr to get dependency info 34 */ 35async function fetchPackageJson(packageName: string, version: string): Promise<PackageJson | null> { 36 try { 37 const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}/package.json` 38 const response = await fetch(url) 39 if (!response.ok) return null 40 return (await response.json()) as PackageJson 41 } catch { 42 return null 43 } 44} 45 46/** 47 * Fetch file content from jsDelivr CDN. 48 */ 49async function fetchFileContent( 50 packageName: string, 51 version: string, 52 filePath: string, 53): Promise<string> { 54 const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}` 55 const response = await fetch(url) 56 57 if (!response.ok) { 58 if (response.status === 404) { 59 throw createError({ statusCode: 404, message: 'File not found' }) 60 } 61 throw createError({ 62 statusCode: 502, 63 message: 'Failed to fetch file from jsDelivr', 64 }) 65 } 66 67 // Check content-length header if available 68 const contentLength = response.headers.get('content-length') 69 if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) { 70 throw createError({ 71 statusCode: 413, 72 message: `File too large (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_FILE_SIZE / 1024}KB.`, 73 }) 74 } 75 76 const content = await response.text() 77 78 // Double-check size after fetching (in case content-length wasn't set) 79 if (content.length > MAX_FILE_SIZE) { 80 throw createError({ 81 statusCode: 413, 82 message: `File too large (${(content.length / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_FILE_SIZE / 1024}KB.`, 83 }) 84 } 85 86 return content 87} 88 89/** 90 * Returns syntax-highlighted HTML for a file in a package. 91 * 92 * URL patterns: 93 * - /api/registry/file/packageName/v/1.2.3/path/to/file.ts 94 * - /api/registry/file/@scope/packageName/v/1.2.3/path/to/file.ts 95 */ 96export default defineCachedEventHandler( 97 async event => { 98 // Parse: [pkg, 'v', version, ...filePath] or [@scope, pkg, 'v', version, ...filePath] 99 const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 100 101 const { rawPackageName, rawVersion: fullPathAfterV } = parsePackageParams(pkgParamSegments) 102 103 // Since version AND path route are required, we split the remainder 104 // fullPathAfterV => "1.2.3/dist/index.mjs" 105 const versionSegments = fullPathAfterV?.split('/') ?? [] 106 107 if (versionSegments.length < 2) { 108 throw createError({ 109 // TODO: throwing 404 rather than 400 as it's cacheable 110 statusCode: 404, 111 message: ERROR_PACKAGE_VERSION_AND_FILE_FAILED, 112 }) 113 } 114 115 // The version is the first segment after 'v', and everything else is the file path 116 const rawVersion = versionSegments[0] 117 const rawFilePath = versionSegments.slice(1).join('/') 118 119 try { 120 const { packageName, version, filePath } = v.parse(PackageFileQuerySchema, { 121 packageName: rawPackageName, 122 version: rawVersion, 123 filePath: rawFilePath, 124 }) 125 126 const content = await fetchFileContent(packageName, version, filePath) 127 const language = getLanguageFromPath(filePath) 128 129 // For JS/TS files, resolve dependency versions and relative imports for linking 130 let dependencies: Record<string, { version: string }> | undefined 131 let resolveRelative: ((specifier: string) => string | null) | undefined 132 133 if (IMPORT_LANGUAGES.has(language)) { 134 // Fetch package.json and file tree in parallel 135 const [pkgJson, fileTreeResponse] = await Promise.all([ 136 fetchPackageJson(packageName, version), 137 getPackageFileTree(packageName, version).catch(() => null), 138 ]) 139 140 // Resolve npm dependency versions 141 if (pkgJson) { 142 // Merge all dependency types 143 const allDeps: Record<string, string> = { 144 ...pkgJson.dependencies, 145 ...pkgJson.peerDependencies, 146 ...pkgJson.optionalDependencies, 147 // Note: excluding devDependencies as they're less likely to be imported in dist files 148 } 149 150 if (Object.keys(allDeps).length > 0) { 151 const resolved: Record<string, string> = await resolveDependencyVersions(allDeps) 152 dependencies = {} 153 for (const [name, ver] of Object.entries(resolved)) { 154 dependencies[name] = { version: ver } 155 } 156 } 157 } 158 159 // Create resolver for relative imports 160 if (fileTreeResponse) { 161 const files = flattenFileTree(fileTreeResponse.tree) 162 resolveRelative = createImportResolver(files, filePath, packageName, version) 163 } 164 } 165 166 const html = await highlightCode(content, language, { 167 dependencies, 168 resolveRelative, 169 }) 170 171 let markdownHtml: ReadmeResponse | undefined 172 if (language === 'markdown') { 173 // Best-effort: markdown preview is optional; never block code view 174 try { 175 const packageData = await fetchNpmPackage(rawPackageName) 176 const repoInfo = parseRepositoryInfo(packageData.repository) 177 markdownHtml = await renderReadmeHtml(content, rawPackageName, repoInfo) 178 } catch { 179 markdownHtml = undefined 180 } 181 } 182 183 return { 184 package: packageName, 185 version, 186 path: filePath, 187 language, 188 content, 189 html, 190 lines: content.split('\n').length, 191 markdownHtml, 192 } 193 } catch (error: unknown) { 194 handleApiError(error, { 195 statusCode: 502, 196 message: 'Failed to fetch file content', 197 }) 198 } 199 }, 200 { 201 // File content for a specific version never changes - cache permanently 202 maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year 203 getKey: event => { 204 const pkg = getRouterParam(event, 'pkg') ?? '' 205 return `file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}` 206 }, 207 }, 208)